区块链学习笔记之Balsn CTF 2019 - Bank

  1. 状态变量储存结构
    1. 映射和动态数组
  2. 漏洞:局部变量未初始化
  3. Balsn CTF 2019 - Bank
    1. 解题步骤
    2. 程序流程

其实这道题应该算是比较过时了,只有solidity 0.5.0 以前可能才会出现的漏洞,感觉主要是结构体未初始化造成的一个变量覆盖,以及程序流的劫持,有一点pwn的感觉在里面。所以通过这道题也是对solidity的存储机制有了一定的了解。

状态变量储存结构

参考登链社区的solidity中文文档,除了映射(mapping) 和 动态数组 的静态大小变量都是从位置 0 开始连续放置在存储(storage)中,如果可能的话,存储大小少于32字节的多个变量会被打包到一个存储插槽中(storage slot),(所以一个slot就是32字节的大小),规则如下:

  1. slot的第一项会以向右对齐的方式存储
  2. 基本类型仅使用存储他们所需字节大小的存储空间
  3. 如果一个slot的剩余空间不足以放下接下来的基本变量,那么它会移到下一个slot
  4. 结构体和数组的数据总是会占用一整个新的slot,但结构体或数组中的每一项还是会以上述规则打包。即不会出现一个slot里出现两个结构体或者两个数组的情况,即使一个结构体或数组也许仅仅占用了2字节。

还有更多比较细节的东西,就不再这篇文章里提出来了,感兴趣的读者可以去看看文档深入了解。

映射和动态数组

由于映射和动态数组的大小是不可预知的,所以他们使用keccak256来计算找到值得位置或数组的起始位置,映射和动态数组本身会根据上述规则在某个位置 $p$ 处占满一个slot(或递归的将该规则应用到映射的映射或者数组的数组),对于动态数组,此slot会存储数组中元素的数量;对于映射,这个插槽不用,但这个茅坑还是得占,这样可以使得两个映射之后会使用不同的散列分布。

对于动态数组,数组的起始位置会位于 keccak256(p) 处,对于映射,映射中的每个键对应的值会位于 keccak256(k||p) 处,(||是连接符,代码:keccak256(abi.encodePacked(k, p)))如果这个值不是基本类型(比如是个结构体),那么就通过加偏移来确定。

例子

1
2
3
4
5
6
7
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.4.0 <0.9.0;

contract C {
struct S { uint a; uint b; }
uint x;
mapping(uint => mapping(uint => S)) data;
}

对于上述合约,data[4][9].b 的位置为keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))) + 1

解释一下,首先 struct S { uint a; uint b; } 只是一个结构体的定义,并没有定义变量,所以不占用slot,uint x 占用slot0,然后 mapping(uint => mapping(uint => S)) data; 会占用slot 1,所以 data[4] 的值的位置就是 keccak256(uint256(4)||uint256(1)),然后这个地方呢不是一个基本类型,是一个 mapping(uint => S) 的映射,所以这个映射占用了slot_keccak256(uint256(4)||uint256(1)),然后再去这个映射的键值为9的值,所以这个地址就是在keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))),再然后,这个地方仍然不是一个基本类型,是一个结构体,这个结构体里 uint a 会占用一个slot,uint b 会占用一个slot,所以 a 的偏移是 0 ,b 的偏移是 1,所以最后为keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))) + 1

漏洞:局部变量未初始化

如果智能合约函数声明了临时的动态数组或者sturct,而没有指定“位置”(storage 还是 memory),且没有进行初始化,那么这些变量将默认为”存储指针”,且指向slot0。

漏洞合约例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract NameRegistrar {
bool public unlocked = false;// 用来锁定注册状态
struct NameRecord {
bytes32 name;
address mappedAddresss;
}
mapping(address =&gt; NameRecord) public registeredNameRecord;
mapping(bytes32 =&gt; address) public resolve;
function register(bytes32 _name, address _mappedAddresss) public {
//构造一个新的 NameRecord
NameRecord newRecord;
newRecord.name = _name;
newRecord.mappedAddresss = _mappedAddresss;
resolve[_name] = _mappedAddresss;
registeredNameRecord[msg.sender] = newRecord;
require(unlocked);//仅在智能合约处在 unlocked 状态下允许注册
}
}

注意到该合约在register中定义了一个newRecord,未指定位置,也没有初始化,所以该结构体的指针指向slot0,如果对name赋值,将修改slot0,从而覆盖unlocked变量,如果name的最后1byte为1,那么unlocked即被修改为True,从而绕过最后的限制。

Balsn CTF 2019 - Bank

好了,搞懂前面三个问题,我们就可以来看看这个题目了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
pragma solidity ^0.4.24;

contract Bank {
event SendEther(address addr);
event SendFlag(address addr);

address public owner;
uint randomNumber = 0;

constructor() public {
owner = msg.sender;
}

struct SafeBox {
bool done;
function(uint, bytes12) internal callback;
bytes12 hash;
uint value;
}
SafeBox[] safeboxes;

struct FailedAttempt {
uint idx;
uint time;
bytes12 triedPass;
address origin;
}
mapping(address => FailedAttempt[]) failedLogs;

modifier onlyPass(uint idx, bytes12 pass) {
if (bytes12(sha3(pass)) != safeboxes[idx].hash) {
FailedAttempt info;
info.idx = idx;
info.time = now;
info.triedPass = pass;
info.origin = tx.origin;
failedLogs[msg.sender].push(info);
}
else {
_;
}
}

function deposit(bytes12 hash) payable public returns(uint) {
SafeBox box;
box.done = false;
box.hash = hash;
box.value = msg.value;
if (msg.sender == owner) {
box.callback = sendFlag;
}
else {
require(msg.value >= 1 ether);
box.value -= 0.01 ether;
box.callback = sendEther;
}
safeboxes.push(box);
return safeboxes.length-1;
}

function withdraw(uint idx, bytes12 pass) public payable {
SafeBox box = safeboxes[idx];
require(!box.done);
box.callback(idx, pass);
box.done = true;
}

function sendEther(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
msg.sender.transfer(safeboxes[idx].value);
emit SendEther(msg.sender);
}

function sendFlag(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
require(msg.value >= 100000000 ether);
emit SendFlag(msg.sender);
selfdestruct(owner);
}

}

合约并不算长。首先我们看拿到flag的条件,不难注意到最后有一个sendFlag函数,会触发SendFlag事件,然后出题人那边部署的Listen监听到后就会给我们发送flag了。调用sendFlag的话,在deposit,如果是合约所有者调用的话,就会把box.callback 改成 sendFlag(顾名思义,猜测这玩意儿应该有点像那个回调函数叭),然后再调用withdraw就会触发这个box的callback,不过box有很多,需要指定一个,然后还得给一个pass,因为sendFlag被onlyPass修饰,要求(bytes12(sha3(pass)) != safeboxes[idx].hash),问题不大,hash和pass都是可控的。至于要求合约所有者调用,这个问题不大,注意到deposit里的 SafeBox box,对box的声明并没有指定位置和初始化,所有该结构体指针是指向slot0的,而存储owner的地方正是在slot0,可以先排一下

1
2
3
4
5
6
7
8
9
-----------------------------------------------------
| unused (12) | owner (20) | <- slot 0
-----------------------------------------------------
| randomNumber (32) | <- slot 1
-----------------------------------------------------
| safeboxes.length (32) | <- slot 2
-----------------------------------------------------
| occupied by failedLogs but unused (32) | <- slot 3
-----------------------------------------------------

然后box的是

1
2
3
4
5
-----------------------------------------------------
| unused (11) | hash (12) | callback (8) | done (1) |
-----------------------------------------------------
| value (32) |
-----------------------------------------------------

所以我们可以控制callback,hash 两个变量的值,还有个done是0,不过没事,我们可以换账户么,换个末尾是0的就行。所以完全可以把原来的owner覆盖成我们自己。但是问题来了,他要求require(msg.value >= 100000000 ether),这个就比较过分了,好像有点难顶。

但是又发现,这个modifier里声明的 FailedAttempt info; 也是未指定位置和初始化的。那它也能改点东西啊。看看它的结构

1
2
3
4
5
6
7
-----------------------------------------------------
| idx (32) |
-----------------------------------------------------
| time (32) |
-----------------------------------------------------
| origin (20) | triedPass (12) |
-----------------------------------------------------

他占三个slot,所以它能改到slot2,也就是safeboxes的长度。safeboxes是一个动态数组,failedLogs 是一个映射,但他们都是存储在storage上的,所以有没有可能,我是说可能,他们是可以重叠的。只要safeboxes的长度比他们各自起始位置的差值的二分之一大就可以了。也就是 keccak245(msg.address()||3) -keccak256(2) < safebox.length // 2 (因为一个box占俩slot)

重叠之后能干嘛,重叠之后 failedLogs 里的 某个 info 通过修改 triedPass 就能覆盖safeboxes里某个box的callback了。把callback覆盖成sendFlag?格局小了,那不还是得要100000000eth,直接给他跳到 emit SendFlag(msg.sender) ,pwn!那我们怎么知道emit SendFlag(msg.sender)的位置在哪儿呢?看汇编,https://ethervm.io/decompile/ropsten/0x85B0446Dc5B5f32cbB674Dc8e49Fc27Ebaff2Ee2 根据100000000eth的特征我们找到image-20220218161934386

(EVM好像只让jump到jumpdest的地方),所以我们往070F跳。

然后这个覆盖是在修饰器里造成的,所以我们需要调用一次deposit ,转进去1 eth使得safeboxes[0] 的 callback 是 sendEther 从而方便之后调用withdraw可以触发修饰器里对info的写。

解题步骤

  1. 计算target = keccak256(keccak256(msg.sender||3)) + 2,这个是 failedLogs [msg.sender].”origin+tridPasss” 的地方,我们要改这里【注意这里有两次keccak,一次是mapping的,一次是failedLogs[]的,实际部署的时候在这里踩坑了】
  2. 计算base = keccak256(2),这个是safeboxes的起始位置
  3. 计算idx = (target-base)//2 这个是要改的位置和safeboxes开始的位置之间能塞多少个box
  4. 如果 (target-base) % 2 == 1,说明不是正正好塞满整数个box,那么idx += 2,我们要用到下两个box,这个box和下一个box都改不到。
  5. 如果 (msg.sender << (12*8)) < idx 得换一个账户,因为safeboxes的长度是用 tx.origin 去覆盖的,最后的值会是 tx.origin << (12*8) + Pass
  6. 用 1 eth 调用一下 deposit(0x000000000000000000000000)
  7. 调用 withdraw(0, 0x111111111111110000070f00),如果step4中 (target-base) % 2 == 1,那么这一步执行两次
  8. 最后再调用一下withdraw(idx, 0x000000000000000000000000) 就能触发emit SendFlag(msg.sender);事件了。

程序流程

前三步应该没有什么问题,只是一个简单的距离计算,就像pwn里面的溢出你需要算填充多少字节一样。

第四步有一个分类讨论了就,如果正好被2整除,那么就是这样子的一个情况

image-20220218173428749

此时我们修改failedLogs [0] 的 pass 就能够改到 safeboxes[idx] 的 callback

但如果是不被2整除,就稍微麻烦些,storage上应该是这样

image-20220218173330674

我们需要修改failedLogs [1] 的 pass 才能改到 safeboxes[idx+2] 的 callback

第六步调用deposit(0x000000000000000000000000),转个 1eth,此时 safeboxes[0].callback = sendEther,safeboxes[0].hash = 0x000000000000000000000000,safeboxes[0].done = false,safeboxes.value = 0.99eth

第七步调用 withdraw(0, 0x111111111111110000070f00) ,此时会调用sendEther函数,进入修饰器,由于不满足 (bytes12(sha3(pass)) != safeboxes[idx].hash),所以开始写info,info.idx = 0,info.time = now,info.triedPass = 0x111111111111110000070f00,info.origin = tx.origin 。注意此时info的值会修改slot0,slot1,slot2的值,所以此时owner=0,randomNumber = now,safeboxes.length = tx.origin << (12*8) + 0x111111111111110000070f00,然后把这个info推进 failedLogs [0],但推进faileLogs[0] 的同时,也把 safeboxes[idx].callback 改成了 111111110000070f

如果之前(target-base) % 2 == 1,那么再执行一次,前面的不变,不过又把一个info推进到了 failedLogs [1] ,此时会把 safeboxes[idx+2].callback 给改了。

最后调用withdraw(idx, 0x000000000000000000000000),执行 box.callback(idx, pass);,此时 box.callback 已经被劫持到了 emit SendFlag(msg.sender) 的位置,触发事件,收flag。

【然而事情并非如我所愿,实际操作的时候卡在最后一步了,】

image-20220218213341705

我这里给的pass是0xdeadbe00000000000008FF00(因为我的jumpdest是08ff),此时我的账户地址是0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,我查的是safeboxes[23098898392122849103790042457787377065045997405586824991915591150521413904160],返回的是数组该处的hash值为0xb03fcb875f56beddc4deadbe,就是我账户地址的后半部分和我pass的前3个字节,说明我调用withdraw后,修饰器写了info,

img

推进failedLogs 的同时也改了safeboxes该处的值。且属于safeboxes该处结构体的callback属性的值应该是00000000000008FF,正好8个字节,然后最后的00是属于done的。那么按理说,我们withdraw数组该处box的时候,会直接执行这个box的callback,也就是0x08ff,但是,,我失败了。

image-20220218213234763

【破案了,兄弟们,搞了半天之后去问zbr,才发现,是反编译的时候,把constructor code给搞进去了,所以偏移错了,不是8ff,是89a,我直接拿着input去反编译的,第一次报错,连汇编都没出来,然后我把复制的0x给删掉了之后,字节码出来了,但是伪代码没出来image-20220218215623573

然后选择性忽略了这一行小字。他说的是我可能把constructor code(不知道具体干啥的,反正应该是部署的时候给JVM看的,也许是设定了JVM部署时要用的参数啊什么什么的,不了解,也没google到,不知道哪里能搞到权威指南看)带进去了,要删掉,通常是从第一个6080(6060)删到第二个6080(6060),日,,删了之后,伪代码也出来了

image-20220218215830157

寄!

pass改成0xdeadbe000000000000089a00

image-20220218215925025

起飞!【踩坑记录就不删了,警醒一下自己属于是】


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可联系QQ 643713081,也可以邮件至 643713081@qq.com

文章标题:区块链学习笔记之Balsn CTF 2019 - Bank

文章字数:3.1k

本文作者:Van1sh

发布时间:2022-02-18, 14:45:00

最后更新:2022-02-23, 09:28:41

原始链接:http://jayxv.github.io/2022/02/18/区块链学习笔记之BalsnCTF 2019 Bank/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏